package animation;

import com.crunch.math.Vector2f;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;

import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;

/**
 * An animation which can be applied to a skeleton.
 */
public class Animation {
	public Animation() {
		animatedBones = new ArrayList<AnimatedBone>();
	}

	public void save(Path path)
			throws IOException {
		Writer writer = null;
		try {
			JSONObject rootObj = new JSONObject();

			JSONObject propertiesObj = new JSONObject();
			rootObj.put("properties", propertiesObj);
			propertiesObj.put("length", length);
			propertiesObj.put("fadeInTime", fadeInTime);
			propertiesObj.put("fadeOutTime", fadeOutTime);

			JSONArray animatedBonesArr = new JSONArray();
			rootObj.put("animatedBones", animatedBonesArr);
			for (AnimatedBone ab : animatedBones) {
				JSONObject animatedBoneObj = new JSONObject();
				animatedBonesArr.put(animatedBoneObj);

				animatedBoneObj.put("boneName", ab.getBoneName());

				JSONArray keyframesArr = new JSONArray();
				animatedBoneObj.put("keyframes", keyframesArr);
				for (int k = 0; k < ab.getKeyframeCount(); ++k) {
					Keyframe kf = ab.getKeyframe(k);

					JSONObject keyframeObj = new JSONObject();
					keyframesArr.put(keyframeObj);

					keyframeObj.put("time", kf.getTime());
					Vector2f translation = kf.getTranslation();
					keyframeObj.put("translation", new JSONArray().put(translation.x()).put(translation.y()));
					keyframeObj.put("rotation", kf.getRotation());
					Vector2f scale = kf.getScale();
					keyframeObj.put("scale", new JSONArray().put(scale.x()).put(scale.y()));
				}
			}

			writer = Files.newBufferedWriter(path, Charset.defaultCharset());
			rootObj.write(writer);
		} catch (JSONException e) {
			System.out.println("Error: " + e.getMessage());
			throw new IOException("Failed to write " + path);
		} finally {
			if (writer != null) {
				try {
					writer.close();
				} catch (IOException e) {}
			}
		}
	}

	public void load(Path path)
			throws IOException {
		animatedBones.clear();
		Reader reader = null;
		try {
			reader = Files.newBufferedReader(path, Charset.defaultCharset());
			JSONObject rootObj = new JSONObject(new JSONTokener(reader));

			JSONObject propertiesObj = rootObj.getJSONObject("properties");
			length = (float) propertiesObj.getDouble("length");
			fadeInTime = (float) propertiesObj.getDouble("fadeInTime");
			fadeOutTime = (float) propertiesObj.getDouble("fadeOutTime");

			if (length < 0.0f || fadeInTime < 0.0f || fadeOutTime < 0.0f) {
				throw new IOException("Invalid properties");
			}

			JSONArray animatedBonesArr = rootObj.getJSONArray("animatedBones");
			for (int i = 0; i < animatedBonesArr.length(); ++i) {
				JSONObject animatedBoneObj = animatedBonesArr.getJSONObject(i);

				String boneName = animatedBoneObj.getString("boneName");
				if (boneName.isEmpty()) {
					throw new IOException("Invalid boneName");
				}

				// make sure it is not duplicate
				for (AnimatedBone animatedBone : animatedBones) {
					if (animatedBone.getBoneName().equals(boneName)) {
						throw new IOException("Duplicate bone specified");
					}
				}

				AnimatedBone animatedBone = addAnimatedBone(boneName);

				// read keyframes
				JSONArray keyframesArr = animatedBoneObj.getJSONArray("keyframes");
				for (int k = 0; k < keyframesArr.length(); ++k) {
					JSONObject keyframeObj = keyframesArr.getJSONObject(k);

					float time = (float) keyframeObj.getDouble("time");
					JSONArray translationArr = keyframeObj.getJSONArray("translation");
					Vector2f translation = new Vector2f(
							(float) translationArr.getDouble(0),
							(float) translationArr.getDouble(1));
					float rotation = (float) keyframeObj.getDouble("rotation");
					JSONArray scaleArr = keyframeObj.getJSONArray("scale");
					Vector2f scale = new Vector2f(
							(float) scaleArr.getDouble(0),
							(float) scaleArr.getDouble(1));

					Keyframe kf = animatedBone.getKeyframe(animatedBone.addKeyframe(time));
					kf.setTranslation(translation);
					kf.setRotation(rotation);
					kf.setScale(scale);
				}
			}
		} catch (JSONException e) {
			System.out.println("Error: " + e.getMessage());
			throw new IOException("Failed to read " + path);
		} catch (IOException e) {
			System.out.println("Error: " + e.getMessage());
			throw new IOException("Failed to read " + path);
		} finally {
			if (reader != null) {
				try {
					reader.close();
				} catch (IOException e) {}
			}
		}
	}

	public static class Keyframe {
		private Keyframe(float time, Vector2f translation, float rotation, Vector2f scale) {
			this.time = time;
			this.translation = translation;
			this.scale = scale;
			this.rotation = rotation;
		}

		public float getTime() {
			return time;
		}

		public Vector2f getTranslation() {
			return new Vector2f(translation);
		}

		public void setTranslation(Vector2f translation) {
			this.translation.set(translation);
		}

		public float getRotation() {
			return rotation;
		}

		public void setRotation(float rotation) {
			this.rotation = rotation;
		}

		public Vector2f getScale() {
			return new Vector2f(scale);
		}

		public void setScale(Vector2f scale) {
			this.scale.set(scale);
		}

		private float time;
		private Vector2f translation;
		private float rotation;
		private Vector2f scale;
	}

	public static class AnimatedBone {
		private AnimatedBone(String boneName) {
			this.boneName = boneName;
			keyframes = new ArrayList<Keyframe>();
		}

		public String getBoneName() {
			return boneName;
		}

		public int getKeyframeCount() {
			return keyframes.size();
		}

		// adds keyframe and returns index
		public int addKeyframe(float time) {
			Keyframe kf = new Keyframe(time, new Vector2f(), 0.0f, new Vector2f(1.0f, 1.0f));
			// find where the keyframe should go
			int pos;
			for (pos = 0; pos < keyframes.size() && time >= keyframes.get(pos).getTime(); ++pos);
			keyframes.add(pos, kf);
			return pos;
		}

		// removes keyframe at index
		public void removeKeyframe(int i) {
			keyframes.remove(i);
		}

		// returns keyframe at index
		public Keyframe getKeyframe(int i) {
			return keyframes.get(i);
		}

		// sets keyframe's time and returns new index
		public int setKeyframeTime(int i, float time) {
			Keyframe kf = keyframes.remove(i);
			kf.time = time;
			// find where the keyframe should go
			int pos;
			for (pos = 0; pos < keyframes.size() && time >= keyframes.get(pos).getTime(); ++pos);
			keyframes.add(pos, kf);
			return pos;
		}

		// returns the index of the keyframe with the largest time that is less than or equal to the given time
		// returns -1 if time is less than the time of the first keyframe
		public int getLowerKeyframe(float time) {
			// perform a binary search
			int b = 0;
			int e = keyframes.size();
			while (b < e) {
				int mid = b + (e - b)/2;
				float kfTime = keyframes.get(mid).getTime();
				if (kfTime > time) {
					e = mid;
				} else if (kfTime < time) {
					b = mid + 1;
				} else {
					return mid;
				}
			}

			if (b == keyframes.size() || keyframes.get(b).getTime() > time) {
				return b-1;
			} else {
				return b;
			}
		}

		// name of the bone in the skeleton
		private String boneName;
		// keyframes
		private ArrayList<Keyframe> keyframes;
	}

	////////
	// accesses the animation

	public int getAnimatedBoneCount() {
		return animatedBones.size();
	}

	public AnimatedBone getAnimatedBone(int i) {
		return animatedBones.get(i);
	}

	// returns animated bone corresponding to the given bone, if it exists
	public AnimatedBone getBone(String boneName) {
		for (AnimatedBone animatedBone : animatedBones) {
			if (animatedBone.getBoneName().equals(boneName))
				return animatedBone;
		}
		return null;
	}

	public float getLength() {
		return length;
	}

	public float getFadeInTime() {
		return fadeInTime;
	}

	public float getFadeOutTime() {
		return fadeOutTime;
	}

	////////
	// modifies the animation

	// adds bone to the animation
	public AnimatedBone addAnimatedBone(String boneName) {
		for (AnimatedBone animatedBone : animatedBones) {
			if (animatedBone.getBoneName().equals(boneName)) {
				throw new IllegalArgumentException("Bone is already animated");
			}
		}

		AnimatedBone ab = new AnimatedBone(boneName);
		animatedBones.add(ab);
		return ab;
	}

	// removes bone from the animation
	public void removeAnimatedBone(String boneName) {
		for (int i = 0; i < animatedBones.size(); ++i) {
			if (animatedBones.get(i).getBoneName().equals(boneName)) {
				animatedBones.remove(i);
				return;
			}
		}

		throw new IllegalArgumentException("Bone is not animated");
	}

	public void setLength(float length) {
		this.length = length;
	}

	public void setFadeInTime(float fadeInTime) {
		this.fadeInTime = fadeInTime;
	}

	public void setFadeOutTime(float fadeOutTime) {
		this.fadeOutTime = fadeOutTime;
	}

	// length of animation
	private float length;
	// fade in/out time of animation
	private float fadeInTime, fadeOutTime;
	// list of animated bones
	private ArrayList<AnimatedBone> animatedBones;
}
